Durable tasks, sandboxed parallel runs, and PR-gated delivery for AI coding agents. Adds Orbit's MCP tools and workflow skills to Claude Code.

<a href="https://orbit-cli.com"> <img src="docs/assets/orbit-lockup-on-light.svg" alt="Orbit" width="340" /> </a>
<h3 align="center">Agents write. Orbit delivers.</h3>
<a href="https://github.com/constellation-works/orbit/releases"><img src="https://img.shields.io/github/v/release/constellation-works/orbit" alt="Release" /></a> <a href="https://www.npmjs.com/package/@orbit-tools/cli"><img src="https://img.shields.io/npm/v/@orbit-tools/cli" alt="npm" /></a> <a href="LICENSE.md"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT" /></a> <a href="https://orbit-cli.com"><img src="https://img.shields.io/badge/docs-orbit--cli.com-informational" alt="Docs" /></a>
<img src="docs/assets/orbit-demo.gif" alt="Animated tour of the real Orbit dashboard while Orbit's agents work on Orbit itself: a proposed task waiting for approval, 16 tasks running at once across two hosts with 100 files locked so overlapping work waits its turn, an auto-drain with 12 running and 19 admitted that pauses new starts while the host runs hot, and auto-concurrency forecasting CPU and memory for each admission; a Codex pull-request pipeline run from isolated worktree through implement, commit, validate, review gate and push to an open pull request, 21 steps in 24 minutes 15 seconds; routines on a host clock and 22 auto-task definitions that keep the queue fed; analytics showing 3,277 tasks done in 30 days, 167% more than the 30 days before, and each model's implementation times; and the codebase view ranking the paths agents struggle with across 6,002 tasks." width="880" />
Orbit is a local-first runtime for coding agents. Keep using your authenticated agent CLI; Orbit adds a durable task queue, sandboxed worktrees, file locks for parallel runs, a gated delivery pipeline, and an audit log.
Why: fast agents make planning, review, and traceability easy to lose. Orbit keeps the prompt, plan, and review behind every change, with a task ID on every workflow commit.
~/.orbit and .orbit/; Orbit sends no telemetry.You: The fsProfile lookup is undocumented. Get that fixed.
Agent: Files a proposed task with acceptance criteria. Approve it and ship?
You: Yes.
Agent: Queues it, then runs plan → execute → review in a locked, isolated worktree.
Opens a pull request. Review and merge it, then approve the task to close it.
Nothing starts without approval; PR runs stop for your review unless you authorize --complete. The task record keeps the prompt, plan, execution trace, and review. For larger specs, ask your agent to orchestrate: the bundled orbit-orchestrate skill splits the work, queues it once approved, runs it in parallel, and diagnoses failures (delivery workflows).
You need: macOS or Linux (x64 or arm64; Windows uses WSL2), Node 18+, an authenticated agent CLI, and authenticated gh for pull requests.
npm install -g @orbit-tools/cli
orbit init # machine name, task-ID prefix, agent skills, and Linux sandbox setup
Open your agent in the repo and ask it to “set up Orbit for this repo”. The bundled orbit-setup skill registers it, connects MCP, runs orbit doctor, and asks for choices such as the PR target branch. Start a fresh agent session, then ask: “Add a hello.txt file containing hello; file it as an Orbit task.” Approve and ship the task when prompted, review and merge the PR, then approve the task to close it. Watch with orbit web serve.
Prefer a plugin? Install Orbit for Claude Code, Codex, or Cursor. Every MCP tool has a CLI twin; for the same loop without an agent, see your first task from the terminal.
cd <repo> && orbit workspace init --mcp # add --ship-mode local to skip PRs
orbit doctor
orbit web serve
Review and commit the checkout files listed by workspace init, including MCP client files, before the first ship. Local delivery refuses tracked changes, merge conflicts, and untracked paths overlapping incoming changes.
orbit doctor checks CLI presence for default, system, and complexity-routed crews and warns when no MCP client is registered; it does not check provider sign-in or MCP connectivity. Use orbit doctor providers for all executor definitions. See Linux sandbox readiness and distro coverage.
| To… | Run |
|---|---|
| Inspect a task or run | orbit task show <ID> · orbit run show <RUN_ID> |
| Open the dashboard | orbit web serve (remote: orbit web connect <host>) |
| Open the dashboard in a desktop window | Build the app in desktop/ |
| Pick the default crew (provider and model) | orbit config set workflow.default_crew <crew> |
| Route tasks to crews by complexity | orbit config set --global workflow.hard_complexity_crews '["opus"]' (more) |
| Upgrade | npm install -g @orbit-tools/cli@latest (orbit update --check shows what's new) |
Crews name a provider, model, and effort level; complexity pools choose one for a task without an explicit crew. See crew definitions, init's seeded pools, and complexity routing, weighting, and overrides.
Connect your agent explains operator and agent-only authority; federated MCP connects multiple machines.
Scoping rules explain machine and workspace state. See the runbooks for backups, recovery, and upgrades.
Pull requests are welcome, from typo fixes to new executors. Small fixes can go straight to a PR; bigger changes start with an issue. See CONTRIBUTING.md.
hooks/mod/register.tsx 570 lines1// The Orbit mod for Claude Code: a band above the prompt, a status line,
2// toasts, and the Board / Ship pane, all read from the `orbit` CLI;
3// plus two hooks: task cards for mentioned task ids, and a `Task:` trailer
4// on commits made while this session works a task.
5//
6// Every function that takes `$` lives in this file: the engine follows `$`
7// only into functions declared beside the hook that holds it.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, ProcessRunResult, Register, Timer } from 'claude-code'
11
12import type { OrbitShip, OrbitView } from '../../types'
13import { argvFor, destinationsArgv, failure, federatedHosts, localArgv, OrbitError, ownerHost, readShown, reason, targetFrom, type Target } from './cli'
14import {
15 card,
16 changes,
17 findRunId,
18 mentions,
19 parseRunEvents,
20 parseShipRun,
21 parseTasks,
22 PIPELINE,
23 preflight,
24 recentlyDone,
25 statusLine,
26 stepStates,
27 toastFor,
28 withTaskTrailer,
29 type ShipRun,
30} from './model'
31import { band } from './views/band'
32import { DEFAULT_OPEN } from './views/board'
33import type { Actions } from './views/kit'
34import { pane } from './views/pane'
35
36const PANE = 'orbit'
37const AFTER_TURN_MIN_MS = 30_000
38const RETRY_EMPTY_MS = 5000
39const RETRY_FAILED_MS = 30_000
40const DEFAULT_COLUMNS = 100
41const SHIP_POLL_MS = 5000
42const TASK_UPDATE = /(^|__)orbit_task_update$/
43const TASK_ID = /^[A-Z][A-Z0-9]{1,11}-\d{1,9}$/
44// Every terminal run state (JobRunState in orbit-types) and the ship phase it ends in; a state not listed is still in flight.
45const OUTCOME: Record<string, OrbitShip['phase']> = {
46 success: 'landed',
47 succeeded: 'landed',
48 failed: 'failed',
49 timeout: 'failed',
50 cancelled: 'failed',
51 interrupted: 'failed',
52 held: 'held',
53 skipped: 'skipped',
54}
55const TITLES: Record<OrbitView, string> = { board: 'Orbit · Board', ship: 'Orbit · Ship' }
56
57const COMMANDS = [
58 { name: 'orbit-board', description: 'Open the Orbit board for this workspace', argumentHint: '[task id]' },
59 { name: 'orbit-ship', description: 'Preflight and ship an Orbit task through the PR pipeline', argumentHint: '[task id]' },
60 { name: 'orbit-band', description: 'Show or hide the Orbit band above the prompt' },
61]
62
63type Settings = { host: string | null; refreshMs: number; commitTrailer: boolean; band: 'on' | 'compact' | 'off' }
64
65const isInFlight = (ship: OrbitShip | null): boolean => ship !== null && (ship.phase === 'launching' || ship.phase === 'flying')
66
67// The session values the mod keeps with Claude Code, declared in
68// plugin/types/index.d.ts; a reload of the module keeps them.
69const snapshotAtom = atom({ plugin: 'orbit', key: 'snapshot' } as const, null)
70const errorAtom = atom({ plugin: 'orbit', key: 'error' } as const, null)
71const isBandHiddenAtom = atom({ plugin: 'orbit', key: 'isBandHidden' } as const, false)
72const activeAtom = atom({ plugin: 'orbit', key: 'active' } as const, null)
73const shipAtom = atom({ plugin: 'orbit', key: 'ship' } as const, null)
74const viewAtom = atom({ plugin: 'orbit', key: 'view' } as const, 'board')
75const selectedAtom = atom({ plugin: 'orbit', key: 'selected' } as const, null)
76const flashAtom = atom({ plugin: 'orbit', key: 'flash' } as const, null)
77const openLanesAtom = atom({ plugin: 'orbit', key: 'openLanes' } as const, null)
78
79let settings: Settings = { host: null, refreshMs: 180_000, commitTrailer: true, band: 'on' }
80let configError: string | null = null
81let cwd = ''
82let target: Target | null = null
83let locating: Promise<Target> | null = null
84let inFlight = false
85let lastRefreshAt = 0
86let shipTimer: Timer | null = null
87let shipTimerRunId: string | null = null
88let shipPolling = false
89let refreshTimer: Timer | null = null
90
91/** Which workspace this session's checkout belongs to, and where its tasks are read. */
92async function locate($: EngineInterface): Promise<Target> {
93 if (target !== null) return target
94 locating ??= findTarget($).finally(() => {
95 locating = null
96 })
97 return locating
98}
99
100async function findTarget($: EngineInterface): Promise<Target> {
101 let shown: { name: string; role: string } | null = null
102 try {
103 const ran = await $.process.run(localArgv(['workspace', 'show', '--format', 'json']), { cwd, timeoutMs: 10_000 })
104 if (ran.exitCode === 0) shown = readShown(ran.stdout)
105 } catch {
106 shown = null
107 }
108 if (shown !== null && shown.role !== 'replica') {
109 target = targetFrom(shown, cwd, null, cwd)
110 return target
111 }
112 let root = cwd
113 if (shown === null) {
114 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 }).catch(() => null)
115 if (top !== null && top.exitCode === 0) root = top.stdout.trim()
116 }
117 const hosts = settings.host !== null ? [settings.host] : await federated($)
118 if (hosts.length <= 1) {
119 target = targetFrom(shown, cwd, hosts[0] ?? null, root)
120 return target
121 }
122 // Several federated owners: the first that answers for the workspace owns it.
123 let failed = ''
124 for (const host of hosts) {
125 const candidate = targetFrom(shown, cwd, host, root)
126 const probe = await $.process.run(argvFor(candidate, ['task', 'list', '--status', 'review', '--json', '--limit', '1']), { cwd, timeoutMs: 15_000 }).catch(() => null)
127 if (probe !== null && probe.exitCode === 0) {
128 target = candidate
129 return target
130 }
131 failed = probe === null ? `${host} timed out` : (failure(candidate, ['task'], probe.exitCode, probe.stdout, probe.stderr) ?? '')
132 }
133 throw new OrbitError(`no federated owner answered for ${shown?.name ?? root}: ${failed}`)
134}
135
136/** The owners Orbit's federated MCP already reaches, when the ownerHost option names none. */
137async function federated($: EngineInterface): Promise<string[]> {
138 const ran = await $.process.run(destinationsArgv, { cwd, timeoutMs: 5000 }).catch(() => null)
139 return ran !== null && ran.exitCode === 0 ? federatedHosts(ran.stdout) : []
140}
141
142async function orbit($: EngineInterface, args: readonly string[], timeoutMs = 20_000): Promise<ProcessRunResult> {
143 const where = await locate($)
144 const ran = await $.process.run(argvFor(where, args), { cwd: where.cwd, timeoutMs })
145 const failed = failure(where, args, ran.exitCode, ran.stdout, ran.stderr)
146 if (failed !== null) throw new OrbitError(failed)
147 return ran
148}
149
150async function publishStatus($: EngineInterface): Promise<void> {
151 $.ui.status(statusLine(await read($, snapshotAtom), await read($, activeAtom), await read($, shipAtom)))
152}
153
154/** Reads the workspace's open tasks and recent completions; toasts what moved since the last read. */
155async function refresh($: EngineInterface): Promise<void> {
156 if (inFlight || configError !== null) return
157 inFlight = true
158 lastRefreshAt = await $.clock.now()
159 try {
160 await bind($)
161 const [open, done] = await Promise.all([
162 orbit($, ['task', 'list', '--status', 'proposed,backlog,in-progress,review,blocked', '--json', '--limit', '500']),
163 orbit($, ['task', 'list', '--status', 'done', '--json', '--limit', '40']),
164 ])
165 const where = await locate($)
166 const at = await $.clock.now()
167 const next = { workspace: where.workspace, host: where.host, tasks: parseTasks(open.stdout), doneRecently: recentlyDone(parseTasks(done.stdout), at), at }
168 const before = await read($, snapshotAtom)
169 for (const change of changes(before, next)) $.ui.toast(toastFor(change), { timeoutMs: 6000 })
170 await update($, snapshotAtom, () => next)
171 await update($, errorAtom, () => null)
172 } catch (thrown) {
173 target = null
174 await update($, errorAtom, () => reason(thrown))
175 } finally {
176 inFlight = false
177 await publishStatus($)
178 }
179}
180
181/**
182 * The session's checkout and the refresh timer, from whichever hook runs first:
183 * a resumed desktop session can draw the band and the pane before this load
184 * sees session.start.
185 */
186async function bind($: EngineInterface): Promise<void> {
187 if (cwd === '') cwd = await $.session.cwd()
188 refreshTimer ??= $.clock.every(settings.refreshMs, () => void refresh($))
189}
190
191/**
192 * Reads the workspace while the board is empty. The read session.start starts
193 * can finish before the session is bound and its write go nowhere, and an
194 * error stored by an earlier load outlives it in the session's state, so the
195 * band, the pane and the commands each ask again: soon while nothing is
196 * known, every 30 seconds while the last read failed.
197 */
198async function ensureLoaded($: EngineInterface): Promise<void> {
199 if (inFlight || configError !== null) return
200 if ((await read($, snapshotAtom)) !== null) return
201 const wait = (await read($, errorAtom)) === null ? RETRY_EMPTY_MS : RETRY_FAILED_MS
202 if ((await $.clock.now()) - lastRefreshAt < wait) return
203 void refresh($)
204}
205
206const columnsOf = (bodyColumns: number | undefined): number => (typeof bodyColumns === 'number' && bodyColumns > 0 ? bodyColumns : DEFAULT_COLUMNS)
207
208async function say($: EngineInterface, text: string | null): Promise<void> {
209 await update($, flashAtom, () => text)
210}
211
212async function confirm($: EngineInterface, question: string, yes: string): Promise<boolean> {
213 try {
214 return (await $.ui.ask(question, [yes, 'Cancel'])) === yes
215 } catch {
216 return false
217 }
218}
219
220async function write($: EngineInterface, args: string[], done: string): Promise<void> {
221 try {
222 await orbit($, args)
223 await say($, done)
224 } catch (thrown) {
225 await say($, `Failed: ${reason(thrown)}`)
226 }
227 await refresh($)
228}
229
230async function openPane($: EngineInterface, next: OrbitView): Promise<boolean> {
231 await update($, viewAtom, () => next)
232 const opened = await $.ui.open({ id: PANE, title: TITLES[next] })
233 return opened.isPlaced
234}
235
236async function setActive($: EngineInterface, taskId: string | null): Promise<void> {
237 await update($, activeAtom, () => taskId)
238 await publishStatus($)
239}
240
241/** Loads a task's preflight into the Ship view; a ship in flight stays put. */
242async function prepareShip($: EngineInterface, taskId: string): Promise<void> {
243 const flying = await read($, shipAtom)
244 if (flying !== null && isInFlight(flying)) {
245 await say($, `${flying.taskId} is still in flight; let it land before preparing another.`)
246 return
247 }
248 const snap = await read($, snapshotAtom)
249 if (snap === null) return
250 let readiness: unknown = null
251 try {
252 readiness = JSON.parse((await orbit($, ['run', 'readiness', taskId, '--json'])).stdout)
253 } catch {
254 readiness = null
255 }
256 const pending: OrbitShip = {
257 taskId,
258 phase: 'preflight',
259 runId: null,
260 checks: preflight(snap.tasks.find(item => item.id === taskId), snap, readiness).checks,
261 steps: PIPELINE.map(() => 'pending'),
262 startedAt: null,
263 finishedAt: null,
264 message: null,
265 }
266 await update($, shipAtom, () => pending)
267 await update($, selectedAtom, () => taskId)
268}
269
270/** `orbit run ship <id>` once the person confirms; then the track follows the run. */
271async function launch($: EngineInterface): Promise<void> {
272 const ready = await read($, shipAtom)
273 if (ready === null || ready.phase !== 'preflight') return
274 if (!(await confirm($, `Ship ${ready.taskId} through the PR pipeline?`, 'Ship it'))) return
275 const now = await $.clock.now()
276 await update($, shipAtom, (prior): OrbitShip | null => (prior ? { ...prior, phase: 'launching', startedAt: now, message: null } : prior))
277 await publishStatus($)
278 try {
279 const ran = await orbit($, ['run', 'ship', ready.taskId, '--json'], 60_000)
280 const runId = findRunId(ran.stdout)
281 await update($, shipAtom, (prior): OrbitShip | null => (prior ? { ...prior, phase: 'flying', runId, message: runId ? null : 'Launched; Orbit reported no run id to follow.' } : prior))
282 if (runId !== null) watchShip($, runId)
283 } catch (thrown) {
284 await update($, shipAtom, (prior): OrbitShip | null => (prior ? { ...prior, phase: 'failed', message: reason(thrown) } : prior))
285 }
286 await publishStatus($)
287}
288
289/** Follows an existing run of a task on the Ship view. */
290async function track($: EngineInterface, taskId: string, runId: string): Promise<void> {
291 const now = await $.clock.now()
292 const tracked: OrbitShip = { taskId, phase: 'flying', runId, checks: [], steps: PIPELINE.map(() => 'pending'), startedAt: now, finishedAt: null, message: null }
293 await update($, shipAtom, () => tracked)
294 watchShip($, runId)
295 await pollShip($)
296}
297
298function watchShip($: EngineInterface, runId: string | null): void {
299 shipTimer?.cancel()
300 shipTimerRunId = runId
301 shipTimer = $.clock.every(SHIP_POLL_MS, () => {
302 if (shipTimerRunId === runId) void pollShip($)
303 })
304}
305
306function stopShipWatch(runId?: string | null): void {
307 if (runId !== undefined && shipTimerRunId !== runId) return
308 shipTimer?.cancel()
309 shipTimer = null
310 shipTimerRunId = null
311}
312
313/** Resolves only this launch's descendants, so an older task run cannot supply its steps. */
314async function pipelineRun($: EngineInterface, runId: string, run: ShipRun, taskId: string, seen = new Set<string>()): Promise<{ runId: string; run: ShipRun } | null> {
315 if (seen.has(runId)) return null
316 seen.add(runId)
317 if (run.job === 'task_pr_pipeline') return { runId, run }
318 for (const childId of run.children) {
319 if (seen.has(childId)) continue
320 const child = parseShipRun((await orbit($, ['run', 'show', childId, '--json'])).stdout)
321 if (!child.taskIds.includes(taskId)) continue
322 const leaf = await pipelineRun($, childId, child, taskId, seen)
323 if (leaf !== null) return leaf
324 }
325 return null
326}
327
328async function pollShip($: EngineInterface): Promise<void> {
329 if (shipPolling) return
330 const flying = await read($, shipAtom)
331 if (flying === null || flying.runId === null || flying.phase !== 'flying') {
332 stopShipWatch()
333 return
334 }
335 const runId = flying.runId
336 shipPolling = true
337 try {
338 const run = parseShipRun((await orbit($, ['run', 'show', runId, '--json'])).stdout)
339 const runState = run.state
340 const outcome = OUTCOME[runState]
341 const isOver = outcome !== undefined
342 const isFailed = outcome === 'failed'
343 // A launch returns task_auto_pipeline; its own events contain no PR steps.
344 // Dispatch records appear before the parent waits, and may arrive on a later poll.
345 const leaf = run.job === 'task_auto_pipeline' || run.job === 'task_gate_pipeline'
346 ? await pipelineRun($, runId, run, flying.taskId)
347 : { runId, run }
348 const steps = leaf === null
349 ? flying.steps
350 : stepStates(parseRunEvents((await orbit($, ['run', 'events', leaf.runId, '--json'])).stdout), isFailed || OUTCOME[leaf.run.state] === 'failed')
351 const now = await $.clock.now()
352 const message = isFailed && run.error !== null ? (run.error.split('\n')[0] ?? '').slice(0, 240) : null
353 await update($, shipAtom, (prior): OrbitShip | null =>
354 prior && prior.runId === runId
355 ? {
356 ...prior,
357 steps,
358 startedAt: run.startedAt ?? prior.startedAt,
359 phase: outcome ?? 'flying',
360 finishedAt: isOver ? now : null,
361 message,
362 }
363 : prior,
364 )
365 const stillTracked = (await read($, shipAtom))?.runId === runId
366 if (isOver && stillTracked) {
367 stopShipWatch(runId)
368 const verdict = outcome === 'landed' ? `✓ ${flying.taskId} landed` : isFailed ? `✗ ${flying.taskId} ship ${runState}` : `■ ${flying.taskId} ship ${runState}`
369 $.ui.toast(`${verdict} · ${runId}`, { timeoutMs: 8000 })
370 void refresh($)
371 }
372 } catch (thrown) {
373 await update($, shipAtom, (prior): OrbitShip | null => (prior && prior.runId === runId ? { ...prior, message: `Tracking paused: ${reason(thrown)}` } : prior))
374 } finally {
375 shipPolling = false
376 }
377 await publishStatus($)
378}
379
380/** The handlers a drawing binds to its Buttons: each a press, never run unasked. */
381function actionsFor($: EngineInterface): Actions {
382 return {
383 refresh: () => void refresh($),
384 setView: next => void openPane($, next),
385 select: taskId => void update($, selectedAtom, () => taskId),
386 toggleLane: async section => {
387 const snapshot = await read($, snapshotAtom)
388 const selected = await read($, selectedAtom)
389 const task = [...(snapshot?.tasks ?? []), ...(snapshot?.doneRecently ?? [])].find(task => task.id === selected)
390 const hasSelection = task?.status === section
391 await update($, openLanesAtom, prior => {
392 const open = prior ?? [...DEFAULT_OPEN]
393 return hasSelection || open.includes(section) ? open.filter(id => id !== section) : [...open, section]
394 })
395 if (hasSelection) await update($, selectedAtom, () => null)
396 },
397 clearFlash: () => void say($, null),
398 hideBand: () => void update($, isBandHiddenAtom, () => true),
399 closePane: () => void $.ui.close({ id: PANE }),
400 approve: taskId => void write($, ['task', 'update', taskId, '--approve'], `${taskId} approved: proposed → backlog`),
401 accept: async taskId => {
402 if (await confirm($, `Accept ${taskId} and mark it done?`, 'Mark done')) await write($, ['task', 'update', taskId, '--approve'], `${taskId} accepted: review → done`)
403 },
404 reject: async taskId => {
405 if (await confirm($, `Reject ${taskId}?`, 'Reject')) await write($, ['task', 'update', taskId, '--status', 'rejected'], `${taskId} rejected`)
406 },
407 workHere: async taskId => {
408 if (!TASK_ID.test(taskId)) return
409 await setActive($, taskId)
410 await say($, `Working on ${taskId} in this session.`)
411 await $.prompt.submit({ text: `Pick up Orbit task ${taskId} in this session: read it, start it with the orbit skill, and work it to review.` })
412 },
413 rescue: async taskId => {
414 if (!TASK_ID.test(taskId)) return
415 await setActive($, taskId)
416 await say($, `Rescuing ${taskId} in this session.`)
417 await $.prompt.submit({ text: `Orbit task ${taskId} is blocked. Diagnose its last run with the orbit-orchestrate skill, then finish the work here and hand it to review.` })
418 },
419 ship: async taskId => {
420 await openPane($, 'ship')
421 await prepareShip($, taskId)
422 },
423 launch: () => void launch($),
424 track: async (taskId, runId) => {
425 await openPane($, 'ship')
426 await track($, taskId, runId)
427 },
428 resetShip: () => void update($, shipAtom, (prior): OrbitShip | null => (isInFlight(prior) ? prior : null)),
429 }
430}
431
432export const register: Register = (on, options) => {
433 try {
434 settings = {
435 host: ownerHost(options.ownerHost),
436 refreshMs: Math.max(1, Number(options.refreshMinutes) || 3) * 60_000,
437 commitTrailer: options.commitTrailer !== false,
438 band: options.band === 'compact' || options.band === 'off' ? options.band : 'on',
439 }
440 } catch (thrown) {
441 configError = reason(thrown)
442 }
443
444 on('session.start', async ($, e, next) => {
445 const started = await next(e)
446 for (const command of COMMANDS) await $.command.register(command)
447 if (configError !== null) {
448 $.ui.toast(`orbit: ${configError}`)
449 return started
450 }
451 cwd = e.cwd
452 target = null
453 // An error stored by an earlier load (another cwd, options since changed) is not this load's.
454 await update($, errorAtom, () => null)
455 void refresh($)
456 const ship = await read($, shipAtom)
457 if (isInFlight(ship)) watchShip($, ship?.runId ?? null)
458 return started
459 })
460
461 on('turn.complete', async ($, e, next) => {
462 const result = await next(e)
463 if ((await read($, snapshotAtom)) === null || (await $.clock.now()) - lastRefreshAt > AFTER_TURN_MIN_MS) void refresh($)
464 return result
465 })
466
467 on('command.run', { command: 'orbit-board' }, async ($, e) => {
468 const id = e.args.trim()
469 if (TASK_ID.test(id)) await update($, selectedAtom, () => id)
470 const isPlaced = await openPane($, 'board')
471 await ensureLoaded($)
472 return { text: isPlaced ? 'Orbit board opened.' : 'The Orbit board opens once the terminal is wide enough.' }
473 })
474
475 on('command.run', { command: 'orbit-ship' }, async ($, e) => {
476 const id = e.args.trim()
477 await openPane($, 'ship')
478 await ensureLoaded($)
479 if (!TASK_ID.test(id)) return { text: 'Orbit ship pane opened.' }
480 await prepareShip($, id)
481 return { text: `Ship preflight for ${id} is in the Orbit pane.` }
482 })
483
484 on('command.run', { command: 'orbit-band' }, async $ => {
485 const wasHidden = await read($, isBandHiddenAtom)
486 await update($, isBandHiddenAtom, () => !wasHidden)
487 if (wasHidden) void refresh($)
488 return { text: wasHidden ? 'Orbit band shown.' : 'Orbit band hidden; /orbit-band brings it back.' }
489 })
490
491 // A mentioned task's card rides along with the prompt, from the last snapshot.
492 on('prompt.submit', async ($, e, next) => {
493 const snap = await read($, snapshotAtom)
494 if (snap === null) return next(e)
495 const known = [...snap.tasks, ...snap.doneRecently]
496 const cards = mentions(e.text, snap).flatMap(id => {
497 const task = known.find(item => item.id === id)
498 return task ? [card(task, snap.workspace)] : []
499 })
500 if (cards.length === 0) return next(e)
501 return next({ ...e, context: [...(e.context ?? []), cards.join('\n\n')] })
502 })
503
504 on('tool.call', async ($, e, next) => {
505 // A commit made while this session works a task carries the task's trailer.
506 if (e.tool === 'Bash' && settings.commitTrailer) {
507 const command = (e as { command?: unknown }).command
508 const taskId = await read($, activeAtom)
509 const rewritten = taskId !== null && typeof command === 'string' ? withTaskTrailer(command, taskId) : null
510 if (rewritten !== null) return next({ ...e, command: rewritten } as typeof e)
511 }
512
513 // Starting or finishing a task through Orbit's MCP tools moves the band with it.
514 if (TASK_UPDATE.test(e.tool)) {
515 const input = e as { id?: unknown; status?: unknown }
516 const ran = await next(e)
517 if (ran.deny === undefined && ran.isError !== true && typeof input.id === 'string') {
518 if (input.status === 'in-progress') await setActive($, input.id)
519 if ((input.status === 'done' || input.status === 'rejected') && (await read($, activeAtom)) === input.id) await setActive($, null)
520 void refresh($)
521 }
522 return ran
523 }
524 return next(e)
525 })
526
527 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
528 if (e.props.hasSurvey || settings.band === 'off' || (await read($, isBandHiddenAtom))) return next(e)
529 const snapshot = await read($, snapshotAtom)
530 const error = configError ?? (await read($, errorAtom))
531 if (snapshot === null) await ensureLoaded($)
532 if (snapshot === null && error === null) return next(e)
533 return band(
534 $.ui.resolve(e),
535 {
536 snapshot,
537 error,
538 active: await read($, activeAtom),
539 ship: await read($, shipAtom),
540 now: await $.clock.now(),
541 columns: columnsOf(e.props.bodyColumns),
542 isCompact: settings.band === 'compact',
543 },
544 actionsFor($),
545 )
546 })
547
548 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
549 const table = $.ui.resolve(e)
550 await ensureLoaded($)
551 return pane(
552 table,
553 {
554 snapshot: await read($, snapshotAtom),
555 error: configError ?? (await read($, errorAtom)),
556 view: await read($, viewAtom),
557 selected: await read($, selectedAtom),
558 openLanes: await read($, openLanesAtom),
559 active: await read($, activeAtom),
560 ship: await read($, shipAtom),
561 flash: await read($, flashAtom),
562 now: await $.clock.now(),
563 columns: columnsOf(e.props.bodyColumns),
564 },
565 actionsFor($),
566 'Svg' in table ? table.Svg : undefined,
567 )
568 })
569}
570hooks/mod/cli.ts 93 lines1// How the Orbit mod reaches Orbit: locally, or over SSH to the `ownerHost`
2// host for a replica checkout. Pure helpers only; register.tsx runs the commands they build.
3
4import { firstLine } from './model'
5
6/** Where the workspace's tasks are read: its registered name, the checkout, and the SSH host (null: this machine). */
7export type Target = { workspace: string; cwd: string; host: string | null }
8
9const HOST = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/
10const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
11
12export class OrbitError extends Error {}
13
14/** A host the person configured, or null; refuses anything SSH could read as an option. */
15export function ownerHost(value: unknown): string | null {
16 if (typeof value !== 'string' || value.trim() === '') return null
17 const host = value.trim()
18 if (!HOST.test(host)) throw new OrbitError(`ownerHost "${host}" is not a host name or SSH alias`)
19 return host
20}
21
22/**
23 * The SSH hosts Orbit's federated MCP muxes: the `orbit host add` file
24 * (`~/.orbit/hosts.toml`, one `ssh = "<host>"` per `[[hosts]]`) or, before its
25 * first migration, the legacy `~/.orbit/mcp-destinations.toml`. In file order;
26 * hosts SSH could read as an option are dropped.
27 */
28export function federatedHosts(toml: string): string[] {
29 const hosts = [...toml.matchAll(/^\s*ssh\s*=\s*"([^"]*)"\s*(?:#.*)?$/gm)].map(match => match[1] ?? '')
30 return [...new Set(hosts.filter(host => HOST.test(host)))]
31}
32
33/** Reads `~/.orbit/hosts.toml`, else the legacy `~/.orbit/mcp-destinations.toml`; empty when neither exists. */
34export const destinationsArgv: readonly string[] = [
35 '/bin/sh',
36 '-c',
37 'cat "$HOME/.orbit/hosts.toml" 2>/dev/null || cat "$HOME/.orbit/mcp-destinations.toml" 2>/dev/null || true',
38]
39
40/** `orbit` from PATH, or from ~/.orbit/bin where the installer puts it. */
41export const localArgv = (args: readonly string[]): string[] => ['/bin/sh', '-c', 'PATH="$HOME/.orbit/bin:$PATH" exec orbit "$@"', 'orbit', ...args]
42
43const quote = (arg: string): string => `'${arg.replace(/'/g, `'\\''`)}'`
44
45export const remoteArgv = (host: string, args: readonly string[]): string[] => [
46 'ssh',
47 '-o',
48 'BatchMode=yes',
49 '-o',
50 'ConnectTimeout=5',
51 '--',
52 host,
53 `PATH="$HOME/.orbit/bin:$PATH" orbit ${args.map(quote).join(' ')}`,
54]
55
56/** The argv that runs `orbit <args> --workspace <name>` for the target. */
57export const argvFor = (target: Target, args: readonly string[]): string[] => {
58 const full = [...args, '--workspace', target.workspace]
59 return target.host === null ? localArgv(full) : remoteArgv(target.host, full)
60}
61
62/** The workspace `orbit workspace show --format json` reports, or null when the checkout is not registered. */
63export function readShown(stdout: string): { name: string; role: string } | null {
64 const parsed: unknown = JSON.parse(stdout)
65 if (typeof parsed !== 'object' || parsed === null) return null
66 const record = parsed as { workspace?: { name?: unknown }; checkout?: { role?: unknown }; registered?: unknown }
67 const name = record.workspace?.name
68 if (record.registered === false || typeof name !== 'string') return null
69 return { name, role: typeof record.checkout?.role === 'string' ? record.checkout.role : 'owner' }
70}
71
72/** Decides the target from what `workspace show` said about the checkout. */
73export function targetFrom(shown: { name: string; role: string } | null, cwd: string, host: string | null, root: string): Target {
74 if (shown !== null && shown.role !== 'replica') return { workspace: shown.name, cwd, host: null }
75 if (shown !== null) {
76 if (host === null) throw new OrbitError(`${shown.name} is a replica here; register its owner with \`orbit host add <ssh-target>\` or set the plugin's ownerHost option`)
77 return { workspace: shown.name, cwd, host }
78 }
79 if (host === null) throw new OrbitError(`no Orbit workspace at ${root}; register it, register its owner with \`orbit host add <ssh-target>\`, or set the plugin's ownerHost option`)
80 const name = root.split('/').filter(Boolean).pop() ?? ''
81 if (!NAME.test(name)) throw new OrbitError(`no Orbit workspace for ${root}`)
82 return { workspace: name, cwd, host }
83}
84
85/** The failure of a finished `orbit` run, or null when it succeeded. */
86export function failure(target: Target, args: readonly string[], exitCode: number, stdout: string, stderr: string): string | null {
87 if (exitCode === 0) return null
88 if (target.host !== null && exitCode === 255) return `${target.host} unreachable`
89 return firstLine(stderr || stdout) || `orbit ${args[0] ?? ''} exited ${exitCode}`
90}
91
92export const reason = (thrown: unknown): string => (thrown instanceof Error ? firstLine(thrown.message) : String(thrown))
93hooks/mod/model.ts 444 lines1// Pure logic of the Orbit mod: parsing `orbit` CLI output and deriving what
2// the band, panes and hooks show. Nothing here touches `$`.
3
4import type { OrbitShip, OrbitSnapshot, OrbitStepState, OrbitTask } from '../../types'
5
6export const OPEN_STATUSES = ['proposed', 'backlog', 'in-progress', 'review', 'blocked'] as const
7
8export type OpenStatus = (typeof OPEN_STATUSES)[number]
9
10export type Lane = { status: OpenStatus; name: string; glyph: string; color: string }
11
12/** Board order, left to right. Glyphs carry status as well as color. */
13export const LANES: readonly Lane[] = [
14 { status: 'proposed', name: 'Proposed', glyph: '○', color: 'gray' },
15 { status: 'backlog', name: 'Backlog', glyph: '·', color: 'white' },
16 { status: 'in-progress', name: 'In progress', glyph: '▶', color: 'cyan' },
17 { status: 'review', name: 'Review', glyph: '◎', color: 'yellow' },
18 { status: 'blocked', name: 'Blocked', glyph: '■', color: 'red' },
19]
20
21export const ACCENT = '#F5A524'
22export const DONE_COLOR = 'green'
23
24export const laneOf = (status: string): Lane | undefined => LANES.find(lane => lane.status === status)
25
26/** The top-level steps of `task_pr_pipeline`, in the order a ship runs them. */
27export const PIPELINE: readonly { id: string; label: string }[] = [
28 { id: 'worktree', label: 'worktree' },
29 { id: 'implement_bundle', label: 'implement' },
30 { id: 'commit', label: 'commit' },
31 { id: 'prepare_branch', label: 'branch' },
32 { id: 'sync_base', label: 'sync' },
33 { id: 'review_gate_admit', label: 'gate in' },
34 { id: 'review', label: 'review' },
35 { id: 'review_gate_settle', label: 'gate out' },
36 { id: 'push', label: 'push' },
37 { id: 'pr_open', label: 'PR' },
38 { id: 'promote_tasks', label: 'promote' },
39 { id: 'complete_pr', label: 'complete' },
40]
41
42const TASK_ID = /\b[A-Z][A-Z0-9]{1,11}-\d{1,9}\b/g
43const RUN_ID = /\bjrun-[A-Za-z0-9-]+\b/
44
45const isRecord = (value: unknown): value is Record<string, unknown> =>
46 typeof value === 'object' && value !== null && !Array.isArray(value)
47
48const text = (value: unknown): string => (typeof value === 'string' ? value : '')
49
50const strings = (value: unknown): string[] =>
51 Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
52
53/** Reads `orbit task list --json` output into slim tasks; tolerates either a bare array or `{ tasks }`. */
54export function parseTasks(stdout: string): OrbitTask[] {
55 const parsed: unknown = JSON.parse(stdout)
56 const rows = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.tasks) ? parsed.tasks : []
57
58 return rows.filter(isRecord).flatMap(row => {
59 const id = text(row.id)
60 if (id === '') return []
61 const deps = strings(row.resolved_dependencies).length > 0 ? strings(row.resolved_dependencies) : strings(row.dependencies)
62
63 return [
64 {
65 id,
66 title: text(row.title),
67 status: text(row.status),
68 priority: text(row.priority) || 'medium',
69 complexity: text(row.complexity) || null,
70 type: text(row.type) || 'feature',
71 crew: text(row.resolved_crew) || text(row.crew) || null,
72 deps,
73 criteria: strings(row.acceptance_criteria).slice(0, 12),
74 runId: text(row.job_run_id) || null,
75 updatedAt: text(row.updated_at),
76 },
77 ]
78 })
79}
80
81/** The tasks of a done listing that reached done within `windowMs` of `now`. */
82export function recentlyDone(done: OrbitTask[], now: number, windowMs = 24 * 3600_000): OrbitTask[] {
83 return done.filter(task => {
84 const at = Date.parse(task.updatedAt)
85 return Number.isFinite(at) && now - at <= windowMs
86 })
87}
88
89export type Counts = Record<OpenStatus, number>
90
91export function tally(tasks: readonly OrbitTask[]): Counts {
92 const counts: Counts = { proposed: 0, backlog: 0, 'in-progress': 0, review: 0, blocked: 0 }
93 for (const task of tasks) {
94 if (task.status in counts) counts[task.status as OpenStatus] += 1
95 }
96 return counts
97}
98
99const PRIORITY_RANK: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 }
100
101/** Highest priority first, then most recently updated. */
102export function byUrgency(a: OrbitTask, b: OrbitTask): number {
103 const rank = (PRIORITY_RANK[a.priority] ?? 2) - (PRIORITY_RANK[b.priority] ?? 2)
104 return rank !== 0 ? rank : b.updatedAt.localeCompare(a.updatedAt)
105}
106
107export const lane = (tasks: readonly OrbitTask[], status: OpenStatus): OrbitTask[] =>
108 tasks.filter(task => task.status === status).sort(byUrgency)
109
110export type Change = { id: string; title: string; to: 'blocked' | 'review' | 'done' }
111
112/** What moved between two snapshots of the same workspace that is worth a toast. */
113export function changes(before: OrbitSnapshot | null, after: OrbitSnapshot): Change[] {
114 if (before === null || before.workspace !== after.workspace || before.host !== after.host) return []
115 const was = new Map(before.tasks.map(task => [task.id, task.status]))
116 const open = new Set(after.tasks.map(task => task.id))
117 const found: Change[] = []
118
119 for (const task of after.tasks) {
120 const prior = was.get(task.id)
121 if (prior === undefined || prior === task.status) continue
122 if (task.status === 'blocked' || task.status === 'review') found.push({ id: task.id, title: task.title, to: task.status })
123 }
124 const announced = new Set(before.doneRecently.map(task => task.id))
125 for (const task of after.doneRecently) {
126 if (was.has(task.id) && !open.has(task.id) && !announced.has(task.id)) found.push({ id: task.id, title: task.title, to: 'done' })
127 }
128 return found
129}
130
131export function toastFor(change: Change): string {
132 if (change.to === 'blocked') return `■ ${change.id} blocked · ${clip(change.title, 60)}`
133 if (change.to === 'review') return `◎ ${change.id} ready for review · ${clip(change.title, 60)}`
134 return `✓ ${change.id} done · ${clip(change.title, 60)}`
135}
136
137/** Task ids a prompt mentions that the snapshot knows, first three, in order. */
138export function mentions(prompt: string, snapshot: OrbitSnapshot): string[] {
139 const known = new Set([...snapshot.tasks, ...snapshot.doneRecently].map(task => task.id))
140 const found: string[] = []
141 for (const match of prompt.matchAll(TASK_ID)) {
142 const id = match[0]
143 if (known.has(id) && !found.includes(id)) found.push(id)
144 if (found.length === 3) break
145 }
146 return found
147}
148
149/** The context block a mentioned task attaches to the prompt. */
150export function card(task: OrbitTask, workspace: string): string {
151 const lines = [
152 `Orbit task ${task.id} (workspace ${workspace}): ${task.title}`,
153 `Status ${task.status} · priority ${task.priority} · ${task.type}${task.complexity ? ` · ${task.complexity}` : ''}${task.crew ? ` · crew ${task.crew}` : ''}`,
154 ]
155 if (task.deps.length > 0) lines.push(`Depends on: ${task.deps.join(', ')}`)
156 if (task.runId) lines.push(`Last run: ${task.runId}`)
157 if (task.criteria.length > 0) lines.push('Acceptance criteria:', ...task.criteria.map(item => `- ${item}`))
158 lines.push('Read the full record with the Orbit task show tool before changing the task.')
159 return lines.join('\n')
160}
161
162// Sticky: tried only where a command can start. The subcommand must end at
163// whitespace or a shell separator, so `commit-tree` and `commit-graph` miss.
164const COMMIT =
165 /(?:(?:[A-Za-z_]\w*=\S*|if|elif|while|until|then|else|do|time|!)\s+)*git(?:\s+-[cC]\s+\S+)*\s+commit(?=[\s;&|()<>]|$)/y
166const COMMAND_START = new Set([';', '&', '|', '(', ')', '{', '\n'])
167
168type Heredoc = { delimiter: string; stripTabs: boolean; end: number }
169
170/** Read a heredoc delimiter as a shell word, removing quotes and escapes. */
171function heredocAt(command: string, start: number): Heredoc | null {
172 if (command.slice(start, start + 2) !== '<<' || command[start + 2] === '<' || command[start - 1] === '<') return null
173 let i = start + 2
174 const stripTabs = command[i] === '-'
175 if (stripTabs) i++
176 while (command[i] === ' ' || command[i] === '\t') i++
177 const wordStart = i
178 let quote: string | null = null
179 let delimiter = ''
180 for (; i < command.length; i++) {
181 const ch = command[i]
182 if (quote === null && /[\s;&|()<>]/.test(ch)) break
183 if (ch === quote) quote = null
184 else if (quote === null && (ch === "'" || ch === '"')) quote = ch
185 else if (ch === '\\' && quote !== "'" && (quote === null || /[$`"\\\n]/.test(command[i + 1] ?? ''))) {
186 const escaped = command[++i]
187 if (escaped === undefined) return null
188 if (escaped !== '\n') delimiter += escaped
189 } else delimiter += ch
190 }
191 return i > wordStart && quote === null ? { delimiter, stripTabs, end: i } : null
192}
193
194/** Bodies start after the command line and are consumed in redirection order. */
195function afterHeredocs(command: string, start: number, heredocs: Heredoc[]): number | null {
196 for (const { delimiter, stripTabs } of heredocs) {
197 let found = false
198 while (start < command.length) {
199 const newline = command.indexOf('\n', start)
200 const end = newline < 0 ? command.length : newline
201 const line = command.slice(start, end)
202 start = newline < 0 ? command.length : newline + 1
203 if ((stripTabs ? line.replace(/^\t+/, '') : line) === delimiter) {
204 found = true
205 break
206 }
207 }
208 if (!found) return null
209 }
210 return start
211}
212
213/** End index of the first `git commit` at a command position outside quotes. */
214function commitEnd(command: string): number | null {
215 let quote: string | null = null
216 let arithmeticDepth = 0
217 let atStart = true
218 const heredocs: Heredoc[] = []
219 for (let i = 0; i < command.length; i++) {
220 const ch = command[i]
221 if (quote !== null) {
222 if (ch === '\\' && quote === '"') i++
223 else if (ch === quote) quote = null
224 continue
225 }
226 // In arithmetic, << is a shift rather than a heredoc redirection.
227 if (arithmeticDepth > 0) {
228 if (ch === '(') arithmeticDepth++
229 else if (ch === ')') arithmeticDepth--
230 else if (ch === '\\') i++
231 else if (ch === "'" || ch === '"') quote = ch
232 continue
233 }
234 if (ch === '(' && command[i + 1] === '(') {
235 arithmeticDepth = 2
236 i++
237 atStart = false
238 continue
239 }
240 if (ch === '\n') {
241 if (heredocs.length > 0) {
242 const end = afterHeredocs(command, i + 1, heredocs)
243 if (end === null) return null
244 i = end - 1
245 heredocs.length = 0
246 }
247 atStart = true
248 continue
249 }
250 // A removed backslash-newline does not change the command position.
251 if (ch === '\\' && command[i + 1] === '\n') {
252 i++
253 continue
254 }
255 if (/\s/.test(ch)) continue
256 if (ch === '#' && (i === 0 || /[\s;&|()<>]/.test(command[i - 1]))) {
257 const newline = command.indexOf('\n', i)
258 if (newline < 0) return null
259 i = newline - 1
260 continue
261 }
262 if (ch === '<') {
263 const heredoc = heredocAt(command, i)
264 if (heredoc !== null) {
265 heredocs.push(heredoc)
266 i = heredoc.end - 1
267 atStart = false
268 continue
269 }
270 }
271 if (atStart) {
272 COMMIT.lastIndex = i
273 if (COMMIT.test(command)) return COMMIT.lastIndex
274 }
275 atStart = COMMAND_START.has(ch)
276 if (ch === '\\') i++
277 else if (ch === "'" || ch === '"') quote = ch
278 }
279 return null
280}
281
282/**
283 * The command with a `Task:` trailer on its first `git commit`, or null when
284 * the command makes no commit there (quoted text and other `git commit-*`
285 * subcommands and heredoc bodies do not count) or already names a trailer or
286 * a task line.
287 */
288export function withTaskTrailer(command: string, taskId: string): string | null {
289 const end = commitEnd(command)
290 if (end === null || /--trailer\b|\bTask:/.test(command)) return null
291 if (/\s--amend\b/.test(command)) return null
292 return `${command.slice(0, end)} --trailer 'Task: ${taskId}'${command.slice(end)}`
293}
294
295export const findRunId = (output: string): string | null => RUN_ID.exec(output)?.[0] ?? null
296
297export type ShipRun = {
298 job: string
299 state: string
300 error: string | null
301 startedAt: number | null
302 taskIds: string[]
303 children: string[]
304}
305
306/** Reads the run and durable gate/delivery dispatches from `orbit run show --json`. */
307export function parseShipRun(stdout: string): ShipRun {
308 const parsed: unknown = JSON.parse(stdout)
309 const run = isRecord(parsed) && isRecord(parsed.run) ? parsed.run : {}
310 const pipeline = isRecord(parsed) && isRecord(parsed.pipeline_state) ? parsed.pipeline_state : {}
311 const dispatches = Array.isArray(pipeline.child_dispatches) ? pipeline.child_dispatches.filter(isRecord) : []
312 const startedAt = Date.parse(text(run.started_at))
313 return {
314 job: text(run.job_id),
315 state: text(run.state) || 'running',
316 error: text(run.error_message) || null,
317 startedAt: Number.isFinite(startedAt) ? startedAt : null,
318 taskIds: strings(run.task_ids),
319 children: dispatches
320 .filter(child => child.job_name === 'task_gate_pipeline' || child.job_name === 'task_pr_pipeline')
321 .map(child => text(child.child_run_id))
322 .filter(id => id !== ''),
323 }
324}
325
326export type RunEvent = { type: string; step: string | null; outcome: string | null }
327
328/** Reads `orbit run events --json` into the step transitions the ship track needs. */
329export function parseRunEvents(stdout: string): RunEvent[] {
330 const parsed: unknown = JSON.parse(stdout)
331 const rows = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.events) ? parsed.events : []
332 return rows.filter(isRecord).map(row => ({
333 type: text(row.event_type) || text(row.type),
334 step: text(row.step_id) || null,
335 outcome: text(row.outcome) || null,
336 }))
337}
338
339/** Latest step transitions; only successful finishes count as done. */
340export function stepStates(events: readonly RunEvent[], isFailed: boolean): OrbitStepState[] {
341 const states = new Map<string, OrbitStepState>()
342 for (const event of events) {
343 if (event.step === null) continue
344 if (event.type === 'step.started') states.set(event.step, isFailed ? 'failed' : 'active')
345 if (event.type === 'step.skipped') states.set(event.step, 'skipped')
346 if (event.type === 'step.finished') {
347 const state = event.outcome === 'success' ? 'done'
348 : event.outcome === 'failed' || event.outcome === 'error' ? 'failed'
349 : event.outcome === 'skipped' ? 'skipped'
350 : 'stopped'
351 states.set(event.step, state)
352 }
353 }
354 return PIPELINE.map(({ id }) => states.get(id) ?? 'pending')
355}
356
357/** The step a ship is on (or stopped at), and how many it has finished. */
358export function shipPosition(ship: OrbitShip): { label: string; done: number } {
359 const done = ship.steps.filter(state => state === 'done').length
360 const at = ship.steps.findIndex(state => state === 'active' || state === 'failed' || state === 'stopped')
361 const label = at >= 0 ? (PIPELINE[at]?.label ?? '') : ship.phase === 'landed' ? 'landed' : ship.phase
362 return { label, done }
363}
364
365export const STEP_GLYPH: Record<OrbitStepState, string> = { done: '●', active: '◉', failed: '✗', stopped: '■', skipped: '↷', pending: '○' }
366
367/** One-line track: `●━●━◉─○─…`. */
368export const track = (steps: readonly OrbitStepState[]): string =>
369 steps.map((state, index) => `${index === 0 ? '' : steps[index - 1] === 'done' ? '━' : '─'}${STEP_GLYPH[state]}`).join('')
370
371export function bar(done: number, total: number, width: number): { filled: string; empty: string } {
372 const cells = total <= 0 ? 0 : Math.round((done / total) * width)
373 return { filled: '━'.repeat(cells), empty: '─'.repeat(Math.max(0, width - cells)) }
374}
375
376export function clock(ms: number): string {
377 const seconds = Math.max(0, Math.floor(ms / 1000))
378 const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
379 const ss = String(seconds % 60).padStart(2, '0')
380 return `T+${mm}:${ss}`
381}
382
383export const ago = (ms: number): string =>
384 ms < 60_000
385 ? 'just now'
386 : ms < 3600_000
387 ? `${Math.floor(ms / 60_000)}m ago`
388 : ms < 48 * 3600_000
389 ? `${Math.floor(ms / 3600_000)}h ago`
390 : `${Math.floor(ms / (24 * 3600_000))}d ago`
391
392export function clip(value: string, width: number): string {
393 if (width <= 1) return value.slice(0, Math.max(0, width))
394 return value.length <= width ? value : `${value.slice(0, width - 1)}…`
395}
396
397/** The first line of a failure, trimmed for a band or a toast. */
398export function firstLine(value: string, width = 120): string {
399 const line = value.split('\n').find(part => part.trim() !== '') ?? ''
400 return clip(line.trim().replace(/^error:\s*/i, ''), width)
401}
402
403/** Status line text: the ship in flight, else the task this session is on, else blocked work. */
404export function statusLine(snapshot: OrbitSnapshot | null, active: string | null, ship: OrbitShip | null): string | undefined {
405 if (ship !== null && (ship.phase === 'launching' || ship.phase === 'flying')) {
406 const { label, done } = shipPosition(ship)
407 return `orbit ⇡ ${ship.taskId} · ${label} ${done}/${PIPELINE.length}`
408 }
409 if (active !== null) {
410 const task = snapshot?.tasks.find(item => item.id === active)
411 return `orbit ◉ ${active}${task ? ` · ${task.status}` : ''}`
412 }
413 const blocked = snapshot ? tally(snapshot.tasks).blocked : 0
414 return blocked > 0 ? `orbit ■ ${blocked} blocked` : undefined
415}
416
417/** Preflight checks for shipping a task, from the snapshot and `orbit run readiness`. */
418export function preflight(task: OrbitTask | undefined, snapshot: OrbitSnapshot, readiness: unknown): { checks: { label: string; isOk: boolean; detail: string }[] } {
419 if (task === undefined) return { checks: [{ label: 'Task is open', isOk: false, detail: 'not in this workspace’s open tasks' }] }
420 const open = new Map(snapshot.tasks.map(item => [item.id, item.status]))
421 const unmet = task.deps.filter(dep => open.has(dep))
422 const explained = isRecord(readiness) && Array.isArray(readiness.tasks) ? readiness.tasks.filter(isRecord).find(row => row.task_id === task.id) : undefined
423 const conflicts = explained && Array.isArray(explained.conflicts) ? explained.conflicts.filter(isRecord) : []
424 const holders = [...new Set(conflicts.map(row => text(row.locking_task_id)).filter(id => id !== ''))]
425
426 const checks = [
427 { label: 'Task is in backlog', isOk: task.status === 'backlog', detail: `${task.id} is ${task.status}` },
428 { label: 'Dependencies are done', isOk: unmet.length === 0, detail: unmet.length === 0 ? (task.deps.length === 0 ? 'none' : task.deps.join(', ')) : `waiting on ${unmet.join(', ')}` },
429 { label: 'No ship already running', isOk: task.runId === null || task.status !== 'in-progress', detail: task.runId ?? 'no run' },
430 ]
431 if (explained !== undefined) {
432 const reason = text(explained.reason)
433 checks.push({
434 label: 'Files are free to lock',
435 isOk: holders.length === 0,
436 detail: holders.length === 0 ? 'no conflicting task' : `held by ${holders.join(', ')}`,
437 })
438 checks.push({ label: 'Ready to start', isOk: explained.eligible === true, detail: explained.eligible === true ? 'eligible now' : reason.replace(/_/g, ' ') || 'not eligible' })
439 } else {
440 checks.push({ label: 'Readiness', isOk: true, detail: 'not reported; the run waits for locks if needed' })
441 }
442 return { checks }
443}
444hooks/mod/views/band.tsx 193 lines1// The band above the prompt: the workspace's counts, the task this session
2// is on, a ship in flight, and the first blocked task.
3
4import type { OrbitShip, OrbitSnapshot } from '../../../types'
5import type { Actions } from './kit'
6import { ACCENT, ago, bar, clip, clock, PIPELINE, shipPosition, tally } from '../model'
7import type { Kit } from './kit'
8
9export type BandData = {
10 snapshot: OrbitSnapshot | null
11 error: string | null
12 active: string | null
13 ship: OrbitShip | null
14 now: number
15 columns: number
16 isCompact: boolean
17}
18
19const RECENT_LANDING_MS = 10 * 60_000
20
21export function band(kit: Kit, data: BandData, act: Actions) {
22 const { Box, Button, Text } = kit
23 const { snapshot, error, active, ship, now, columns } = data
24
25 const hide = <Button key="hide" label="Hide" plain dimColor onPress={act.hideBand} />
26 const board = <Button key="board" label="Board" plain dimColor onPress={() => act.setView('board')} />
27
28 if (snapshot === null) {
29 return (
30 <Box>
31 <Text dimColor wrap="truncate-end">
32 orbit · {error ?? 'reading…'}{' '}
33 </Text>
34 {hide}
35 </Box>
36 )
37 }
38
39 const counts = tally(snapshot.tasks)
40 const isFlying = ship !== null && (ship.phase === 'launching' || ship.phase === 'flying')
41 const isRecentLanding = ship !== null && (ship.phase === 'landed' || ship.phase === 'failed' || ship.phase === 'held' || ship.phase === 'skipped') && ship.finishedAt !== null && now - ship.finishedAt < RECENT_LANDING_MS
42
43 if (data.isCompact || columns < 80) {
44 return (
45 <Box>
46 <Text wrap="truncate-end">
47 <Text bold>orbit</Text> <Text color="cyan">▶{counts['in-progress']}</Text>{' '}
48 <Text color={counts.blocked > 0 ? 'red' : undefined} dimColor={counts.blocked === 0}>
49 ■{counts.blocked}
50 </Text>{' '}
51 <Text color="yellow">◎{counts.review}</Text>
52 {isFlying && ship ? <Text color={ACCENT}> ⇡{ship.taskId}</Text> : null}
53 {!isFlying && active ? <Text color={ACCENT}> ◉{active}</Text> : null}{' '}
54 </Text>
55 {board}
56 </Box>
57 )
58 }
59
60 const where = `${snapshot.workspace}${snapshot.host ? `@${snapshot.host}` : ''}`
61 const age = error ? `stale: ${error}` : ago(now - snapshot.at)
62 const blocked = snapshot.tasks.find(task => task.status === 'blocked')
63 const focus = active ? snapshot.tasks.find(task => task.id === active) : undefined
64 const isBusy = isFlying || isRecentLanding || active !== null
65 const titleRoom = Math.max(12, columns - 70)
66
67 const lead =
68 (isFlying || isRecentLanding) && ship ? (
69 shipLead(kit, ship, now)
70 ) : active !== null ? (
71 <Text>
72 <Text bold color={ACCENT}>
73 ◉ {active}
74 </Text>{' '}
75 {clip(focus?.title ?? 'not in the open tasks', titleRoom)}
76 <Text dimColor> · {focus?.status ?? 'closed'} </Text>
77 </Text>
78 ) : null
79
80 return (
81 <Box flexDirection="column">
82 <Box>
83 {lead}
84 <Text wrap="truncate-end">
85 {isBusy ? null : <Text bold>orbit</Text>}
86 {isBusy ? null : <Text dimColor> {where} </Text>}
87 <Text color={counts['in-progress'] > 0 ? 'cyan' : undefined} dimColor={counts['in-progress'] === 0}>
88 {isBusy ? ` ▶${counts['in-progress']}` : ` ▶ ${counts['in-progress']} running`}
89 </Text>
90 <Text color={counts.blocked > 0 ? 'red' : undefined} dimColor={counts.blocked === 0}>
91 {isBusy ? ` ■${counts.blocked}` : ` ■ ${counts.blocked} blocked`}
92 </Text>
93 <Text color={counts.review > 0 ? 'yellow' : undefined} dimColor={counts.review === 0}>
94 {isBusy ? ` ◎${counts.review}` : ` ◎ ${counts.review} review`}
95 </Text>
96 <Text dimColor>{isBusy ? ' ' : ` ${counts.backlog} backlog · ${counts.proposed} proposed · ${age} `}</Text>
97 </Text>
98 {isFlying || isRecentLanding ? <Button key="track" label="Track" plain dimColor onPress={() => act.setView('ship')} /> : null}
99 {isFlying || isRecentLanding ? <Text> </Text> : null}
100 {board}
101 <Text> </Text>
102 {hide}
103 </Box>
104 {blocked ? (
105 <Box>
106 <Text wrap="truncate-end">
107 <Text bold color="red">
108 ■ {blocked.id} blocked
109 </Text>{' '}
110 {clip(blocked.title, Math.max(12, columns - 48))}{' '}
111 </Text>
112 <Button
113 key="open-blocked"
114 label="Open"
115 plain
116 dimColor
117 onPress={() => {
118 act.select(blocked.id)
119 act.setView('board')
120 }}
121 />
122 <Text> </Text>
123 <Button key="rescue" label="Rescue here" plain onPress={() => void act.rescue(blocked.id)} />
124 </Box>
125 ) : null}
126 </Box>
127 )
128}
129
130function shipLead(kit: Kit, ship: OrbitShip, now: number) {
131 const { Text } = kit
132 const { label, done } = shipPosition(ship)
133 const { filled, empty } = bar(done, PIPELINE.length, 16)
134 const elapsed = ship.startedAt === null ? '' : ` ${clock((ship.finishedAt ?? now) - ship.startedAt)}`
135
136 if (ship.phase === 'landed') {
137 return (
138 <Text>
139 <Text bold color="green">
140 ✓ {ship.taskId} landed
141 </Text>
142 <Text dimColor>
143 {' '}
144 {ship.runId ?? ''}
145 {elapsed}{' '}
146 </Text>
147 </Text>
148 )
149 }
150 if (ship.phase === 'failed') {
151 return (
152 <Text>
153 <Text bold color="red">
154 ✗ {ship.taskId} ship failed
155 </Text>
156 <Text dimColor> at {label} </Text>
157 </Text>
158 )
159 }
160 if (ship.phase === 'held' || ship.phase === 'skipped') {
161 return (
162 <Text>
163 <Text bold color={ship.phase === 'held' ? 'yellow' : undefined}>
164 ■ {ship.taskId} ship {ship.phase}
165 </Text>
166 <Text dimColor>
167 {' '}
168 {ship.runId ?? ''}
169 {elapsed}{' '}
170 </Text>
171 </Text>
172 )
173 }
174 return (
175 <Text>
176 <Text bold color={ACCENT}>
177 ⇡ shipping {ship.taskId}
178 </Text>
179 <Text dimColor> {ship.runId ?? 'launching'} </Text>
180 <Text color="cyan">{label}</Text>
181 <Text dimColor>
182 {' '}
183 {done}/{PIPELINE.length}{' '}
184 </Text>
185 <Text color={ACCENT}>{filled}</Text>
186 <Text dimColor>
187 {empty}
188 {elapsed}{' '}
189 </Text>
190 </Text>
191 )
192}
193hooks/mod/views/board.tsx 160 lines1// The Board view: one collapsible section per status, most urgent first, each
2// task a full-width row whose details open beneath it when pressed.
3
4import type { OrbitSnapshot, OrbitTask } from '../../../types'
5import type { Actions } from './kit'
6import { ACCENT, ago, DONE_COLOR, lane, LANES, laneOf, type OpenStatus } from '../model'
7import type { Kit } from './kit'
8
9export type BoardData = {
10 snapshot: OrbitSnapshot
11 selected: string | null
12 active: string | null
13 /** Sections the person opened; null keeps the defaults (every one but Done). */
14 openLanes: string[] | null
15 now: number
16}
17
18type Section = { id: string; name: string; glyph: string; color: string; tasks: OrbitTask[] }
19
20/** Attention first: what is stuck, then what is moving, then what waits. */
21const ORDER: readonly OpenStatus[] = ['blocked', 'in-progress', 'review', 'proposed', 'backlog']
22export const DONE_SECTION = 'done'
23export const DEFAULT_OPEN: readonly string[] = ORDER
24const ROW_LIMIT = 20
25/** Priorities worth a color; medium and low read as the default. */
26const PRIORITY_COLOR: Record<string, string> = { critical: 'red', high: 'yellow' }
27
28export const isOpen = (openLanes: string[] | null, id: string): boolean => (openLanes ?? DEFAULT_OPEN).includes(id)
29
30export function boardView(kit: Kit, data: BoardData, act: Actions) {
31 const { Box, Text } = kit
32 const { snapshot } = data
33 const sections: Section[] = [
34 ...ORDER.map(status => {
35 const spec = LANES.find(item => item.status === status)
36 return { id: status, name: spec?.name ?? status, glyph: spec?.glyph ?? '·', color: spec?.color ?? 'white', tasks: lane(snapshot.tasks, status) }
37 }),
38 { id: DONE_SECTION, name: 'Done today', glyph: '✓', color: DONE_COLOR, tasks: snapshot.doneRecently },
39 ]
40
41 return (
42 <Box flexDirection="column" rowGap={1}>
43 {snapshot.tasks.length === 0 ? <Text dimColor>No open tasks in {snapshot.workspace}.</Text> : null}
44 {sections.map(section => sectionView(kit, section, data, act))}
45 </Box>
46 )
47}
48
49function sectionView(kit: Kit, section: Section, data: BoardData, act: Actions) {
50 const { Box, Button, Text } = kit
51 const count = section.tasks.length
52 const isQuiet = count === 0
53 const selected = section.tasks.find(task => task.id === data.selected)
54 // An empty section is its header alone, whatever the person left it as.
55 const open = !isQuiet && (selected !== undefined || isOpen(data.openLanes, section.id))
56 const rows = section.tasks.slice(0, ROW_LIMIT)
57 // Keep the resource bound while giving direct-ID selection a visible card.
58 if (selected && !rows.includes(selected)) rows[ROW_LIMIT - 1] = selected
59
60 return (
61 <Box key={`section:${section.id}`} flexDirection="column">
62 <Box columnGap={1}>
63 <Text dimColor>{isQuiet ? ' ' : open ? '▾' : '▸'}</Text>
64 <Text color={isQuiet ? undefined : section.color} dimColor={isQuiet}>
65 {section.glyph}
66 </Text>
67 <Button key={`lane:${section.id}`} label={section.name} plain dimColor={isQuiet} onPress={() => act.toggleLane(section.id)} />
68 <Text dimColor>{count}</Text>
69 </Box>
70 {open ? (
71 <Box flexDirection="column" paddingLeft={2} rowGap={0}>
72 {rows.map(task => taskRow(kit, task, data, act))}
73 {count > ROW_LIMIT ? <Text dimColor>{`+${count - ROW_LIMIT} more · /orbit-board <id> opens one`}</Text> : null}
74 </Box>
75 ) : null}
76 </Box>
77 )
78}
79
80function taskRow(kit: Kit, task: OrbitTask, data: BoardData, act: Actions) {
81 const { Box, Button, Text } = kit
82 const isSelected = data.selected === task.id
83 const isMine = data.active === task.id
84 const updated = Date.parse(task.updatedAt)
85 const urgent = PRIORITY_COLOR[task.priority]
86 const meta = [task.type, task.crew, Number.isFinite(updated) ? ago(data.now - updated) : null].filter(Boolean).join(' · ')
87
88 return (
89 <Box key={`row:${task.id}`} flexDirection="column" marginTop={isSelected ? 1 : 0} marginBottom={isSelected ? 1 : 0}>
90 <Box columnGap={1}>
91 <Text color={ACCENT}>{isSelected ? '▾' : isMine ? '◉' : ' '}</Text>
92 <Button key={`card:${task.id}`} label={task.id} plain dimColor={!isSelected && !isMine} onPress={() => act.select(isSelected ? null : task.id)} />
93 <Box flexDirection="column" flexShrink={1}>
94 <Text bold={isSelected} wrap="wrap">
95 {task.title}
96 </Text>
97 <Text wrap="truncate-end">
98 {urgent ? <Text color={urgent}>{task.priority} · </Text> : null}
99 <Text dimColor>{meta}</Text>
100 </Text>
101 </Box>
102 </Box>
103 {isSelected ? details(kit, task, data, act) : null}
104 </Box>
105 )
106}
107
108function details(kit: Kit, task: OrbitTask, data: BoardData, act: Actions) {
109 const { Box, Button, Text } = kit
110 const spec = laneOf(task.status)
111 const facts = [task.complexity ? `complexity ${task.complexity}` : null, task.deps.length > 0 ? `depends on ${task.deps.join(', ')}` : null, task.runId ? `last run ${task.runId}` : null].filter(Boolean)
112 const buttons: { key: string; label: string; hotkey: string; isPrimary?: boolean; press: () => void }[] = []
113
114 if (task.status === 'proposed') {
115 buttons.push({ key: 'approve', label: 'Approve', hotkey: 'a', isPrimary: true, press: () => act.approve(task.id) })
116 buttons.push({ key: 'reject', label: 'Reject', hotkey: 'x', press: () => void act.reject(task.id) })
117 }
118 if (task.status === 'backlog') {
119 buttons.push({ key: 'ship', label: 'Ship…', hotkey: 's', isPrimary: true, press: () => void act.ship(task.id) })
120 buttons.push({ key: 'work', label: 'Work here', hotkey: 'w', press: () => void act.workHere(task.id) })
121 }
122 if (task.status === 'in-progress') {
123 if (task.runId) buttons.push({ key: 'track', label: 'Track run', hotkey: 't', isPrimary: true, press: () => void act.track(task.id, task.runId ?? '') })
124 if (data.active !== task.id) buttons.push({ key: 'work', label: 'Work here', hotkey: 'w', press: () => void act.workHere(task.id) })
125 }
126 if (task.status === 'review') {
127 buttons.push({ key: 'accept', label: 'Accept → done', hotkey: 'a', isPrimary: true, press: () => void act.accept(task.id) })
128 buttons.push({ key: 'work', label: 'Work here', hotkey: 'w', press: () => void act.workHere(task.id) })
129 }
130 if (task.status === 'blocked') {
131 buttons.push({ key: 'rescue', label: 'Rescue here', hotkey: 'r', isPrimary: true, press: () => void act.rescue(task.id) })
132 if (task.runId) buttons.push({ key: 'track', label: 'Open run', hotkey: 't', press: () => void act.track(task.id, task.runId ?? '') })
133 }
134
135 return (
136 <Box key="details" flexDirection="column" marginLeft={2} marginTop={1} borderStyle="round" borderColor={spec?.color} borderDimColor paddingX={1}>
137 {facts.length > 0 ? (
138 <Text dimColor wrap="wrap">
139 {facts.join(' · ')}
140 </Text>
141 ) : null}
142 {task.criteria.length > 0 ? <Text bold>Acceptance criteria</Text> : <Text dimColor>No acceptance criteria recorded.</Text>}
143 {task.criteria.slice(0, 8).map((item, index) => (
144 <Box key={`criterion:${index}`} columnGap={1}>
145 <Text dimColor>○</Text>
146 <Text wrap="wrap">{item}</Text>
147 </Box>
148 ))}
149 {task.criteria.length > 8 ? <Text dimColor>+{task.criteria.length - 8} more criteria</Text> : null}
150 {buttons.length > 0 ? (
151 <Box marginTop={1} columnGap={1} flexWrap="wrap">
152 {buttons.map(button => (
153 <Button key={button.key} label={button.label} hotkey={button.hotkey} variant={button.isPrimary ? 'primary' : undefined} onPress={button.press} />
154 ))}
155 </Box>
156 ) : null}
157 </Box>
158 )
159}
160hooks/mod/views/kit.ts 31 lines1import type { BoxProps, ButtonProps, ElementConstructor, TextProps } from 'claude-code'
2
3import type { OrbitView } from '../../../types'
4
5/** The elements every surface draws that the Orbit views use. */
6export type Kit = {
7 Box: ElementConstructor<BoxProps>
8 Text: ElementConstructor<TextProps>
9 Button: ElementConstructor<ButtonProps>
10}
11
12/** What the views' Buttons run: each a press, never run unasked. */
13export type Actions = {
14 refresh: () => void
15 setView: (view: OrbitView) => void
16 select: (taskId: string | null) => void
17 toggleLane: (section: string) => void
18 clearFlash: () => void
19 hideBand: () => void
20 closePane: () => void
21 approve: (taskId: string) => void
22 accept: (taskId: string) => Promise<void>
23 reject: (taskId: string) => Promise<void>
24 workHere: (taskId: string) => Promise<void>
25 rescue: (taskId: string) => Promise<void>
26 ship: (taskId: string) => Promise<void>
27 launch: () => void
28 track: (taskId: string, runId: string) => Promise<void>
29 resetShip: () => void
30}
31hooks/mod/views/pane.tsx 77 lines1// The Orbit pane: a tab row (Board, Ship), a one-line notice, and the
2// view the person picked.
3
4import type { ElementConstructor, SvgProps } from 'claude-code'
5
6import type { OrbitShip, OrbitSnapshot, OrbitView } from '../../../types'
7import type { Actions } from './kit'
8import { ACCENT, ago } from '../model'
9import { boardView } from './board'
10import type { Kit } from './kit'
11import { shipView } from './ship'
12
13export type PaneData = {
14 snapshot: OrbitSnapshot | null
15 error: string | null
16 view: OrbitView
17 selected: string | null
18 openLanes: string[] | null
19 active: string | null
20 ship: OrbitShip | null
21 flash: string | null
22 now: number
23 columns: number
24}
25
26const TABS: readonly { view: OrbitView; label: string; hotkey: string }[] = [
27 { view: 'board', label: 'Board', hotkey: '1' },
28 { view: 'ship', label: 'Ship', hotkey: '2' },
29]
30
31export function pane(kit: Kit, data: PaneData, act: Actions, Svg?: ElementConstructor<SvgProps>) {
32 const { Box, Button, Text } = kit
33 const { snapshot, error, view } = data
34 const where = snapshot ? `${snapshot.workspace}${snapshot.host ? `@${snapshot.host}` : ''} · ${error ? `stale: ${error}` : ago(data.now - snapshot.at)}` : ''
35
36 let body
37 if (snapshot === null) {
38 body = <Text dimColor>{error ? `Orbit unavailable: ${error}` : 'Reading the workspace…'}</Text>
39 } else if (view === 'ship') {
40 body = shipView(kit, { snapshot, ship: data.ship, now: data.now, columns: data.columns }, act, Svg)
41 } else {
42 body = boardView(kit, { snapshot, selected: data.selected, active: data.active, openLanes: data.openLanes, now: data.now }, act)
43 }
44
45 return (
46 <Box flexDirection="column">
47 <Box columnGap={1} flexWrap="wrap">
48 {TABS.map(tab => (
49 <Button
50 key={`tab:${tab.view}`}
51 label={tab.label}
52 hotkey={tab.view === view ? undefined : tab.hotkey}
53 variant={tab.view === view ? 'primary' : undefined}
54 onPress={() => act.setView(tab.view)}
55 />
56 ))}
57 <Text dimColor wrap="truncate-end">
58 {where}
59 </Text>
60 <Button key="refresh" label="Refresh" hotkey="g" plain dimColor onPress={act.refresh} />
61 <Button key="close" label="Close" role="dismiss" plain dimColor onPress={act.closePane} />
62 </Box>
63 {data.flash ? (
64 <Box columnGap={1}>
65 <Text color={ACCENT} wrap="truncate-end">
66 {data.flash}
67 </Text>
68 <Button key="dismiss-flash" label="×" plain dimColor onPress={act.clearFlash} />
69 </Box>
70 ) : null}
71 <Box marginTop={1} flexDirection="column">
72 {body}
73 </Box>
74 </Box>
75 )
76}
77hooks/mod/views/ship.tsx 182 lines1// The Ship view: preflight checks and the launch, then the run's progress
2// through the twelve steps of the PR pipeline.
3
4import type { ElementConstructor, SvgProps } from 'claude-code'
5
6import type { OrbitShip, OrbitSnapshot, OrbitStepState } from '../../../types'
7import type { Actions } from './kit'
8import { ACCENT, clip, clock, lane, PIPELINE, shipPosition, STEP_GLYPH } from '../model'
9import type { Kit } from './kit'
10
11export type ShipData = {
12 snapshot: OrbitSnapshot
13 ship: OrbitShip | null
14 now: number
15 columns: number
16}
17
18const STEP_COLOR: Record<OrbitStepState, string | undefined> = { done: ACCENT, active: 'cyan', failed: 'red', stopped: 'yellow', skipped: undefined, pending: undefined }
19
20const PHASE: Record<OrbitShip['phase'], { label: string; color: string | undefined }> = {
21 preflight: { label: 'READY', color: undefined },
22 launching: { label: 'LAUNCHING', color: 'cyan' },
23 flying: { label: 'IN FLIGHT', color: 'cyan' },
24 landed: { label: 'LANDED', color: 'green' },
25 failed: { label: 'FAILED', color: 'red' },
26 held: { label: 'HELD', color: 'yellow' },
27 skipped: { label: 'SKIPPED', color: undefined },
28}
29
30export function shipView(kit: Kit, data: ShipData, act: Actions, Svg?: ElementConstructor<SvgProps>) {
31 const { Box, Button, Text } = kit
32 const { snapshot, ship, now, columns } = data
33
34 if (ship === null) {
35 const ready = lane(snapshot.tasks, 'backlog')
36 return (
37 <Box flexDirection="column">
38 <Text>Choose a backlog task to ship.</Text>
39 {ready.length === 0 ? <Text dimColor>The backlog is empty.</Text> : null}
40 {ready.slice(0, 12).map(task => (
41 <Button key={`pick:${task.id}`} label={clip(`${task.id} ${task.title}`, Math.max(12, columns - 4))} plain dimColor onPress={() => void act.ship(task.id)} />
42 ))}
43 </Box>
44 )
45 }
46
47 const task = [...snapshot.tasks, ...snapshot.doneRecently].find(item => item.id === ship.taskId)
48 const phase = PHASE[ship.phase]
49 const elapsed = ship.startedAt === null ? 'T+00:00' : clock((ship.finishedAt ?? now) - ship.startedAt)
50 const meta = [task?.crew ? `crew ${task.crew}` : null, task?.priority, snapshot.workspace, ship.runId].filter(Boolean).join(' · ')
51
52 return (
53 <Box flexDirection="column">
54 <Box justifyContent="space-between">
55 <Text bold color={ACCENT}>
56 SHIP · {ship.taskId}
57 </Text>
58 <Text>
59 <Text color={phase.color} bold>
60 {phase.label}
61 </Text>
62 <Text dimColor> {elapsed}</Text>
63 </Text>
64 </Box>
65 <Text bold>{task?.title ?? 'not in the open tasks'}</Text>
66 <Text dimColor wrap="truncate-end">
67 {meta}
68 </Text>
69 {ship.phase === 'preflight' ? preflightBlock(kit, ship, act) : flightBlock(kit, ship, columns, act, Svg)}
70 </Box>
71 )
72}
73
74function preflightBlock(kit: Kit, ship: OrbitShip, act: Actions) {
75 const { Box, Button, Text } = kit
76 const isClear = ship.checks.slice(0, 2).every(check => check.isOk)
77 return (
78 <Box flexDirection="column" marginTop={1}>
79 <Text dimColor>Preflight</Text>
80 {ship.checks.map(check => (
81 <Text wrap="truncate-end">
82 <Text color={check.isOk ? 'green' : 'red'}>{check.isOk ? '✓' : '✗'}</Text> {check.label}
83 <Text dimColor> · {check.detail}</Text>
84 </Text>
85 ))}
86 <Box marginTop={1} columnGap={1}>
87 {isClear ? <Button key="launch" label="Launch" hotkey="l" variant="primary" onPress={act.launch} /> : null}
88 <Button key="back" label="Back to board" hotkey="b" onPress={() => act.setView('board')} />
89 <Button key="clear" label="Clear" hotkey="c" onPress={act.resetShip} />
90 </Box>
91 {isClear ? (
92 <Text dimColor>Launch runs orbit run ship {ship.taskId} after you confirm.</Text>
93 ) : (
94 <Text color="red">Only a backlog task with its dependencies done can ship.</Text>
95 )}
96 </Box>
97 )
98}
99
100function flightBlock(kit: Kit, ship: OrbitShip, columns: number, act: Actions, Svg?: ElementConstructor<SvgProps>) {
101 const { Box, Button, Text } = kit
102 const { label, done } = shipPosition(ship)
103 const perRow = Math.max(1, Math.min(4, Math.floor(columns / 16)))
104 const rows: number[][] = []
105 for (let start = 0; start < PIPELINE.length; start += perRow) rows.push(PIPELINE.slice(start, start + perRow).map((_, offset) => start + offset))
106 const isOver = ship.phase === 'landed' || ship.phase === 'failed' || ship.phase === 'held' || ship.phase === 'skipped'
107
108 return (
109 <Box flexDirection="column" marginTop={1}>
110 {Svg ? <Svg source={trajectory(ship.steps)} alt={`Ship track: ${done} of ${PIPELINE.length} steps done, at ${label}`} width={Math.min(720, columns * 8)} /> : null}
111 <Text wrap="truncate-end">
112 {ship.steps.map((state, index) => (
113 <Text color={STEP_COLOR[state]} dimColor={state === 'pending'}>
114 {index === 0 ? '' : ship.steps[index - 1] === 'done' ? '━' : '─'}
115 {STEP_GLYPH[state]}
116 </Text>
117 ))}
118 <Text dimColor>
119 {' '}
120 {label} {done}/{PIPELINE.length}
121 </Text>
122 </Text>
123 <Box flexDirection="column" marginTop={1}>
124 {rows.map(row => (
125 <Box>
126 {row.map(index => {
127 const state = ship.steps[index] ?? 'pending'
128 return (
129 <Box width={16}>
130 <Text color={STEP_COLOR[state]} dimColor={state === 'pending'}>
131 {STEP_GLYPH[state]} {PIPELINE[index]?.label}
132 </Text>
133 </Box>
134 )
135 })}
136 </Box>
137 ))}
138 </Box>
139 {ship.message ? <Text color={ship.phase === 'failed' ? 'red' : undefined}>{ship.message}</Text> : null}
140 {ship.phase === 'landed' ? <Text color="green">Landed. The task moves on to review; its PR is open against the base branch.</Text> : null}
141 {ship.phase === 'held' ? <Text color="yellow">Held. Delivery is waiting on external review evidence; the run is over and the task stays as it is.</Text> : null}
142 {ship.phase === 'skipped' ? <Text dimColor>Skipped. The run was skipped and is no longer tracked.</Text> : null}
143 <Box marginTop={1} columnGap={1}>
144 {ship.phase === 'failed' ? <Button key="rescue" label="Rescue here" hotkey="r" variant="primary" onPress={() => void act.rescue(ship.taskId)} /> : null}
145 <Button key="back" label="Back to board" hotkey="b" onPress={() => act.setView('board')} />
146 {isOver ? <Button key="clear" label="Clear" hotkey="c" onPress={act.resetShip} /> : null}
147 </Box>
148 {ship.phase === 'flying' ? <Text dimColor>Safe to keep working; the band and status line follow the run.</Text> : null}
149 </Box>
150 )
151}
152
153/** The ship track as a rising trajectory, for surfaces that draw SVG. */
154export function trajectory(steps: readonly OrbitStepState[]): string {
155 const width = 720
156 const height = 170
157 const count = PIPELINE.length
158 const point = (index: number) => {
159 const f = index / (count - 1)
160 return { x: 24 + f * (width - 48), y: 140 - 110 * Math.sin((f * Math.PI) / 2) }
161 }
162 const path = (upTo: number) =>
163 Array.from({ length: upTo + 1 }, (_, index) => point(index))
164 .map((p, index) => `${index === 0 ? 'M' : 'L'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
165 .join(' ')
166 const reached = steps.reduce((last, state, index) => (state === 'pending' ? last : index), -1)
167 const fill: Record<OrbitStepState, string> = { done: ACCENT, active: '#0B0E14', failed: '#FF7A66', stopped: '#E9C46A', skipped: 'none', pending: 'none' }
168 const ring: Record<OrbitStepState, string> = { done: ACCENT, active: '#4CC9F0', failed: '#FF7A66', stopped: '#E9C46A', skipped: '#6E788C', pending: '#6E788C' }
169
170 const nodes = steps
171 .map((state, index) => {
172 const p = point(index)
173 const labelY = index < 4 ? p.y + 22 : p.y - 14
174 const name = PIPELINE[index]?.label ?? ''
175 return `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${state === 'active' ? 8 : 6}" fill="${fill[state]}" stroke="${ring[state]}" stroke-width="2"/><text x="${p.x.toFixed(1)}" y="${labelY.toFixed(1)}" text-anchor="middle" font-family="ui-monospace, monospace" font-size="11" fill="${state === 'pending' ? '#8A93A6' : ring[state]}">${name}</text>`
176 })
177 .join('')
178
179 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><path d="${path(count - 1)}" fill="none" stroke="#6E788C" stroke-opacity="0.5" stroke-width="2" stroke-dasharray="4 6"/>${reached > 0 ? `<path d="${path(reached)}" fill="none" stroke="${ACCENT}" stroke-width="3" stroke-linecap="round"/>` : ''}${nodes}</svg>`
180}
181
182types/index.d.ts 65 lines1// State contract of the Orbit mod (plugin/hooks/mod): the values the band,
2// status line and panes draw from, held by Claude Code for the session.
3
4/** One open or recently finished task, slimmed from `orbit task list --json`. */
5export type OrbitTask = {
6 id: string
7 title: string
8 status: string
9 priority: string
10 complexity: string | null
11 type: string
12 crew: string | null
13 deps: string[]
14 criteria: string[]
15 runId: string | null
16 updatedAt: string
17}
18
19/** The checkout's workspace as last read, and where it was read from. */
20export type OrbitSnapshot = {
21 workspace: string
22 /** SSH host the workspace was read through; null when read locally. */
23 host: string | null
24 tasks: OrbitTask[]
25 /** Tasks that reached `done` in the last 24 hours. */
26 doneRecently: OrbitTask[]
27 at: number
28}
29
30export type OrbitStepState = 'pending' | 'active' | 'done' | 'failed' | 'stopped' | 'skipped'
31
32export type OrbitCheck = { label: string; isOk: boolean; detail: string }
33
34/** A ship this session is preparing, flying or has landed. */
35export type OrbitShip = {
36 taskId: string
37 /** `held` and `skipped` are terminal run states that neither landed nor failed. */
38 phase: 'preflight' | 'launching' | 'flying' | 'landed' | 'failed' | 'held' | 'skipped'
39 runId: string | null
40 checks: OrbitCheck[]
41 steps: OrbitStepState[]
42 startedAt: number | null
43 finishedAt: number | null
44 message: string | null
45}
46
47export type OrbitView = 'board' | 'ship'
48
49declare module 'claude-code' {
50 interface PluginState {
51 orbit: {
52 snapshot: OrbitSnapshot | null
53 error: string | null
54 isBandHidden: boolean
55 active: string | null
56 ship: OrbitShip | null
57 view: OrbitView
58 selected: string | null
59 /** Board sections the person has open; null keeps the defaults. */
60 openLanes: string[] | null
61 flash: string | null
62 }
63 }
64}
65